iT邦幫忙

2023 iThome 鐵人賽

DAY 20
0
自我挑戰組

重新複習JavaScript系列 第 20

[Day -20] Closures

  • 分享至 

  • xImage
  •  

在正式進入之前,我們先來看一段code:

function greet(whattosay) {

   return function(name) {
       console.log(whattosay + ' ' + name);
   }

}

greet('Hi')('Tony')
// 輸出會是:Hi Tony

好像有點難懂,我們換另一種寫法:

function greet(whattosay) {

   return function(name) {
       console.log(whattosay + ' ' + name);
   }

}

var sayHi = greet('Hi');
sayHi('Tony');
//輸出會是: Hi Tony

看起來好像很合理,但如果仔細一想,有一個疑問:為什麼sayHi會知道whattosay這個參數。

我的想法是:whattosay是在我們called greet function時創建的,而當greet這個function執行完成後參數whattosay應該會從execution stack 離開才對。為什麼sayHi還能找到呢?

這就是我們要學的closures所帶來的結果。

什麼是Closures

先來一段MDN的說明:

closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function's scope from an inner function. In JavaScript, closures are created every time a function is created, at function creation time.

簡單來說,閉包是指當內部 function 引用了外部 function 的變數時,這些變數會被保存在閉包內,使得即使外部 function 執行完畢後,內部函式依然可以訪問和操作這些變數。

一般而言,當function被執行後,function 內的資料就會被銷毀,從而釋放記憶體空間。

但有了這項特性後,我就可以利用這個特性來保存我們想要的資料。

Closures的底層原理

當我們執行整段code時,我們知道整段code的global execution context會被建立。

當我們來到 var sayHi = greet(’Hi’) 時,他會invokes greet這個function,創建新的execution context

當我們要執行function greet(whattosay)時,javaScript引擎會注意到這裡有一個parameters(參數),因此把他放到了execution context裡。

當再往下執行後,發現了return ,因此回傳了後面整段function,所以整個greet()就會從stack彈出。

但要記得,我們說過每個execution context在記憶體裡都會有一個空間,在正常情況下,JavaScript引擎會透過garbage collection來清除內容,但在execution context抽離的當下,雖然execution context已經不在了,但裡面的變數還是儲存在那個記憶體位置。

當我們繼續往下執行到sayHi('Tony')時,我們建立了一個給匿名函式的execution context,同時裡面帶有變數name。

當我們執行這個這個匿名函式的console.log(whattosay + ' ' + name)時,JavaScript引擎就會過scope chain的方式來尋找whattosay這個變數。這時候雖然我們的greet這個function的execution context已經不在了,但其實在這個記憶體位置仍然留有參照(reference),所以在greet function裡面所建立的函式仍然可以找得到whattosay這個變數。

到這邊就是整個closure的底層原理。

Closure的練習

function buildFunctions() {
 
    var arr = [];

    for (var i = 0; i < 3; i++) {
        
        arr.push(
            function() {
                console.log(i);   
            }
        )      
    }
    
    return arr;
}

var fs = buildFunctions();

fs[0]();
fs[1]();
fs[2]();

看到第一眼本來很直覺的想要說答案是0,1,2,但在仔細看了一遍後注意到arr裡面放的是function而不是number。

所以console.log(i)並不會在被執行,而是當我們程式走到fs[0]()時,他才會去執行console.log(i),所以透過scope chain去找到i的值。

buildFunctions這個function執行for迴圈時,每當執行一次,就會把 function( ){console.log(i)} 儲存到陣列中,但要注意的是這時候這個被儲到陣列中的function並沒有執行(invoke),而是只是儲存在裡面而已,因為它沒有透過括號 ( ) 來執行;然後 i 會繼續累加,當 i 累加到3的時候,因為不符合 i < 3 所以會跳出迴圈。因此i就會是等於3,而arr會是長這樣:

arr = [f0, f1, f2]

因此當我們透過scope chain去找i時就會得到i = 3,之後再帶入到程式碼裡面就會得到答案3。由於f0、f1跟f2都擁有同樣的outer environment reference,因此答案會是:3,3,3。

那如果這時候我們要讓他輸出的結果變成 0, 1, 2時,我們該怎麼做?

這邊提到了2個做法,第一個是使用let,第二個就是使用IIFEs

先看let:

function buildFunctions() {
 
    var arr = [];
    
    for (var i = 0; i < 3; i++) {
        let j = i;
        arr.push(
            function() {
                console.log(j);   
            }
        )
        
    }
    
    return arr;
}

透過let,可以讓每次跑的迴圈都建立在一個新的記憶體位置,因此最後指到的地方會是不一樣的,於是可以輸出0, 1, 2的結果。

使用IIFE就稍微複雜了:

function buildFunctions() {
 
    var arr = [];
    
    for (var i = 0; i < 3; i++) {
        arr.push(
            (function(j) {
                return function() {
                    console.log(j);   
                }
            }(i))
        )
        
    }
    
    return arr;
}

(function(j){...})(i)因為這段是IIFE,所以他會直接被執行並且會把變數i帶到function裏面,這也就導致在一開始原本一樣的outer environment reference變成不一樣,因此當我們要再去outer environment reference 找參數時,就會因為參數的值不同,得到的輸出就不會再是都一樣的。

|Closure的進階練習

function makeGreeting(language) {
 
    return function(firstname, lastname) {
     
        if (language === 'en') {
            console.log('Hello ' + firstname + ' ' + lastname);   
        }

        if (language === 'es') {
            console.log('Hola ' + firstname + ' ' + lastname);   
        }
        
    }
    
}

var greetEnglish = makeGreeting('en');
var greetSpanish = makeGreeting('es');

greetEnglish('John', 'Doe');
greetSpanish('John', 'Doe');

閉包的使用當然有很多種,例如上面這段code,雖然比前一個練習更複雜,但我們只要記得:

每執行一次函式,就會產生一個新的execution context,即使有多個參數值被儲存在記憶體中,JavaScript引擎會自己找到屬於該execution context的變數。

Closure的總結

closure是JavaScript引擎的一種特性,並不是說你需要去創造它或執行它。透過closure這樣的特性,我們可以確保當我們在執行function的時候,JavaScript引擎能夠找到其相對應的變數,也就是說,不論某一個function是不是已經執行完畢,是不是已經抽離execution stack,JavaScript引擎仍然可以找到外面的變數。

補充:callback function

callback function指的是在一個function(例如,funcA)中放入另一個function(例如,funcB),而且當這個funcA執行完後會觸發funcB的執行。

// 為了確保先執行 funcA 再執行 funcB
// 我們在 funcA 加上 callback 參數
var funcA = function(callback){
  var i = Math.random() + 1;

  window.setTimeout(function(){
    console.log('function A');

    // 如果 callback 是個函式就呼叫它
    if( typeof callback === 'function' ){
      callback();
    }

  }, i * 1000);
};

var funcB = function(){
  var i = Math.random() + 1;

  window.setTimeout(function(){
    console.log('function B');
  }, i * 1000);
};

// 將 funcB 作為參數帶入 funcA()
funcA( funcB );

Closure的觀念非常重要,在重新整理過一遍筆記後,發現自己還是有不是那麼了解的地方。

因此又重新看了一遍**JavaScript: Understanding the Weird Part 。**

結果字幕沒有中文了…還好只是複習一下自己忘記的地方,不然完全不懂的情況下要我聽英文看英文字幕學習我應該辦不到….


上一篇
[Day -19] IIFE
下一篇
[Day -21] Event Loop
系列文
重新複習JavaScript30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言